Linux 信号机制:产生、阻塞与捕捉
信号是 Linux 里最古老的进程间通信方式:事件发生时内核对进程的异步通知,也称为软件中断。Ctrl+C 结束进程、kill -9 强杀、段错误时的 core dump,背后都是信号。本文写给需要正确处理信号的多进程/长驻程序开发者,覆盖信号的产生、默认动作、常用接口、signal 与 sigaction 两种捕捉方式、阻塞与未决的关系,以及用 SIGCHLD 回收僵尸进程的完整实践。
信号是什么
信号是软件层次上对中断机制的模拟,一种异步通信方式:进程在任何时刻都可能被打断,转去执行信号处理函数,处理完再回到断点。
信号通常源于内核,产生信号的典型事件:
- 用户在终端输入特殊字符(Ctrl+C 发送 SIGINT、Ctrl+\ 发送 SIGQUIT)
- 硬件异常(除零、非法内存访问触发 SIGFPE、SIGSEGV)
- 系统状态变化(alarm 定时器到期的 SIGALRM、子进程结束的 SIGCHLD)
- 其他进程调用
kill()或kill命令
信号的特点:简单、不能携带大量信息、优先级高。kill -l 查看全部信号,前 31 个是常规信号,其余是实时信号。
常见信号
| 编号 | 信号 | 触发条件 | 默认动作 |
|---|---|---|---|
| 2 | SIGINT | 用户按下 Ctrl+C | 终止进程 |
| 3 | SIGQUIT | 用户按下 Ctrl+\ | 终止进程 |
| 9 | SIGKILL | 无条件终止 | 终止进程(不可捕获/忽略/阻塞) |
| 11 | SIGSEGV | 非法内存访问 | 终止进程并产生 core 文件 |
| 13 | SIGPIPE | 向没有读端的管道写数据 | 终止进程 |
| 17 | SIGCHLD | 子进程终止/停止/继续 | 忽略 |
| 18 | SIGCONT | 使停止的进程继续 | 继续 |
| 19 | SIGSTOP | 停止进程 | 停止(不可捕获/忽略/阻塞) |
默认处理动作共五种:Term(终止)、Ign(忽略)、Core(终止并转储核心)、Stop(暂停)、Cont(继续)。SIGKILL 和 SIGSTOP 不能被捕捉、阻塞或忽略,这是系统保留的最后控制手段。
产生与定时接口
#include <signal.h>
int kill(pid_t pid, int sig);
// pid > 0 发给指定进程;= 0 发给本进程组;= -1 发给所有有权限的进程;
// < -1 发给组 id 为 -pid 的进程组。sig 为 0 时不发信号,可用于探测进程是否存在
int raise(int sig); // C 库函数,给当前进程发信号,等价于 kill(getpid(), sig)
void abort(void); // 给当前进程发 SIGABRT
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// seconds 秒后给本进程发 SIGALRM;返回上一个 alarm 的剩余秒数(无则 0);
// alarm(0) 取消定时。每个进程同一时刻只有一个 alarm 在计时。
// 计时用的是自然时间,与进程状态无关(睡眠中也计时),函数本身不阻塞
#include <sys/time.h>
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
// 更高精度(微秒)且支持周期性定时的替代方案:
// ITIMER_REAL 按真实时间计时,到期发 SIGALRM
// ITIMER_VIRTUAL 按用户态 CPU 时间计时,到期发 SIGVTALRM
// ITIMER_PROF 按用户态+内核态时间计时,到期发 SIGPROF setitimer 的参数结构:
struct itimerval {
struct timeval it_interval; // 周期间隔,非零则周期性触发
struct timeval it_value; // 首次到期时间
};
struct timeval {
time_t tv_sec; // 秒
suseconds_t tv_usec; // 微秒
}; 捕捉信号
signal:简单但少用
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
// handler 取值:SIG_IGN(忽略)、SIG_DFL(默认动作)、或回调函数 void f(int)
// 返回值:成功返回该信号先前的处置(上一次的 handler 或 SIG_IGN/SIG_DFL,
// 之前未设置过时通常是 SIG_DFL);失败返回 SIG_ERR 回调函数由内核调用:信号递达时,内核保存被打断的上下文,转到处理函数执行,结束后恢复断点继续运行。
sigaction:推荐的方式
signal 的语义在不同 UNIX 平台有历史差异(信号递达后处置是否自动重置等),可移植的写法是用 sigaction:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int); // 处理函数(配合 sa_flags = 0)
void (*sa_sigaction)(int, siginfo_t *, void *); // 三参版本(配合 SA_SIGINFO)
sigset_t sa_mask; // 处理函数执行期间额外阻塞的信号集
int sa_flags; // 0 用 sa_handler;SA_SIGINFO 用三参版本
void (*sa_restorer)(void); // 已废弃,不使用
}; 一个重要保证:处理函数执行期间,内核自动阻塞当前正在处理的这个信号(防止处理函数被自己递归打断),函数返回后自动解除;sa_mask 里还可以追加其他要临时阻塞的信号。
内核处理信号的过程
信号检查发生在内核态返回用户态的边界上,所以一个正在长时间系统调用(如 read 阻塞)中的进程,信号递达会先中断系统调用(通常返回 EINTR),处理完信号后再由调用方决定是否重启。
阻塞与未决
内核为每个进程维护两个位图信号集:
- 未决信号集:信号已产生但尚未递达。信号产生时对应位置 1,递达时清 0。常规信号不排队:同一信号产生多次未处理,未决位也只保持 1,不计数
- 阻塞信号集:被阻塞的信号停留在未决状态不递达,直到解除阻塞。阻塞是「暂不处理」,不是「丢弃」,也不是「不产生」
流程:信号产生 → 未决位置 1 → 内核检查阻塞位 → 未阻塞则递达处理,阻塞则保持未决。
用户不能直接操作内核里的两个集合,需要先构造自定义信号集,再通过接口设置:
#include <signal.h>
int sigemptyset(sigset_t *set); // 全部清 0
int sigfillset(sigset_t *set); // 全部置 1
int sigaddset(sigset_t *set, int signum); // 添加一个信号
int sigdelset(sigset_t *set, int signum); // 删除一个信号
int sigismember(const sigset_t *set, int signum); // 查询:在返回 1,不在返回 0
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
// 把自定义集合作用到内核阻塞信号集:
// SIG_BLOCK 并集:mask |= set
// SIG_UNBLOCK 解除:mask &= ~set
// SIG_SETMASK 替换:mask = set
int sigpending(sigset_t *set); // 读取当前未决信号集(只读) SIGCHLD 与僵尸进程
子进程退出后,PCB 并不立即释放,要等父进程 wait 回收;父进程不回收,子进程就成为僵尸进程(zombie),积累多了耗尽进程号。子进程终止、停止、继续三种变化都会给父进程发 SIGCHLD(默认动作是忽略)。
标准做法:捕捉 SIGCHLD,在处理函数里循环 waitpid,一次信号把积压的僵尸全部收掉:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <sys/wait.h>
void myFun(int num) {
// 循环回收:一次 SIGCHLD 可能对应多个已退出的子进程
while (1) {
int ret = waitpid(-1, NULL, WNOHANG);
if (ret > 0) {
printf("child died, pid = %d\n", ret);
} else {
break; // 返回 0:还有活着的子进程;返回 -1:没有子进程了
}
}
}
int main() {
// 先阻塞 SIGCHLD,防止 fork 后子进程立刻退出、
// 而父进程还没注册完处理函数,信号按默认动作(忽略)丢掉
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGCHLD);
sigprocmask(SIG_BLOCK, &set, NULL);
pid_t pid;
for (int i = 0; i < 20; i++) {
pid = fork();
if (pid == 0) {
break; // 子进程不参与 fork 循环
}
}
if (pid > 0) {
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = myFun;
sigemptyset(&act.sa_mask);
sigaction(SIGCHLD, &act, NULL); // 注册完捕捉
sigprocmask(SIG_UNBLOCK, &set, NULL); // 再解除阻塞,补收挂起的信号
while (1) {
printf("parent pid: %d\n", getpid());
sleep(2);
}
} else {
printf("child pid: %d\n", getpid());
}
return 0;
} 四个常见疑问
1. 为什么处理函数里不能随便用 printf?
printf 维护 stdio 内部缓冲区和数据结构,属于不可重入函数。若主流程执行 printf 到一半被信号打断,处理函数里再调 printf,两次执行交叉修改同一份内部状态,输出混乱甚至崩溃。信号处理函数里只能使用异步信号安全函数(man 7 signal-safety 有完整列表);示例代码用 printf 仅为演示。
2. 为什么循环 waitpid 能回收「被忽略过」的僵尸?
waitpid 与 SIGCHLD 并不绑定:它只认僵尸,不关心信号。信号不排队、一次 SIGCHLD 只触发一次处理函数,但循环里的 waitpid 见一个收一个,直到没有僵尸为止,所以能一次清完积压。
3. 阻塞中的信号会丢吗?
不会。阻塞只是暂不递达,信号保留在未决集中(位保持 1);解除阻塞后照常递达。因此示例里「先阻塞 → 注册处理函数 → 解除阻塞」的顺序能保证注册前产生的 SIGCHLD 不丢,注册后立刻被处理。
4. 为什么有时进了处理函数,却一个僵尸都没回收到?
处理函数执行期间同类型信号被自动阻塞。期间其余子进程退出产生的 SIGCHLD 只置未决位;而循环里的 waitpid 已经把这些子进程连同此前的僵尸全部回收了。处理函数返回、阻塞解除后,未决的 SIGCHLD 再次递达,又进一次处理函数,此时没有僵尸,waitpid 返回 0 或 -1 直接退出。这是正常现象,不是逻辑错误。
另一个工程要点:《Linux/UNIX 系统编程手册》建议为保证可移植性,应在创建任何子进程之前就设置好 SIGCHLD 的处理方式——上述「先阻塞再 fork」是等价的实现手段。
要点
- 信号是异步的软件中断;SIGKILL/SIGSTOP 不可捕获、阻塞、忽略
- 产生 → 未决 → (查阻塞集)→ 递达;常规信号不排队,阻塞不等于丢弃
- 捕捉优先用 sigaction,不用语义有历史差异的 signal;处理函数执行期间本信号自动阻塞
- 处理函数里只用异步信号安全函数,printf/malloc 都不安全
- 回收僵尸:捕捉 SIGCHLD + 循环 waitpid(WNOHANG);注册前先阻塞信号防丢失